Skip to content

Add AvailableBalances::next_splice_out_maximum_sat#4550

Open
tankyleo wants to merge 5 commits intolightningdevkit:mainfrom
tankyleo:2026-04-next-splice-out-maximum
Open

Add AvailableBalances::next_splice_out_maximum_sat#4550
tankyleo wants to merge 5 commits intolightningdevkit:mainfrom
tankyleo:2026-04-next-splice-out-maximum

Conversation

@tankyleo
Copy link
Copy Markdown
Contributor

@tankyleo tankyleo commented Apr 9, 2026

This is the maximum value the holder can splice out of their channel balance, accounting for the updated v2 reserves after the splice, and the "must have at least one output" constraint on zero-reserve channels.

@ldk-reviews-bot
Copy link
Copy Markdown

ldk-reviews-bot commented Apr 9, 2026

👋 Thanks for assigning @TheBlueMatt as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@ldk-reviews-bot ldk-reviews-bot requested a review from jkczyz April 9, 2026 08:13
Comment thread lightning/src/ln/channel_state.rs Outdated
Comment thread lightning/src/sign/tx_builder.rs
Comment thread lightning/src/sign/tx_builder.rs Outdated
Comment thread lightning/src/ln/channel.rs
Comment thread lightning/src/routing/router.rs Outdated
Comment thread lightning/src/routing/router.rs Outdated
@tankyleo tankyleo force-pushed the 2026-04-next-splice-out-maximum branch from 724acf9 to 3bd7a76 Compare April 9, 2026 08:29
@ldk-claude-review-bot
Copy link
Copy Markdown
Collaborator

ldk-claude-review-bot commented Apr 9, 2026

I've completed a thorough review of every file and hunk in the diff, cross-referencing with the surrounding codebase. All significant issues were already identified in my prior review pass.

Review Summary

No new bugs or security issues found beyond what was previously flagged.

Previously Flagged Issues (Still Valid)

These remain from the prior review:

  1. lightning/src/sign/tx_builder.rs:355 — Dust limit inconsistency between get_next_splice_out_maximum_sat (uses holder_dust_limit_satoshis) and FundingScope::for_splice (uses MIN_CHAN_DUST_LIMIT_SATOSHIS for the counterparty-selected reserve). For deserialized channels where holder_dust_limit > 354 and the channel is small, the debug assertion unwrap_err() at channel.rs:13441 could panic.

  2. lightning/src/sign/tx_builder.rs:399-416 / lightning/src/ln/channel.rs:13433has_output only checks the local commitment, but validate_splice_contributions (called by the debug assertions) checks both commitments. If counterparty_dust_limit > holder_dust_limit in a zero-reserve unbalanced channel, the debug assertion unwrap() at channel.rs:13433 could panic.

  3. lightning/src/sign/tx_builder.rs:387 — Comment says "free to withdraw up to its post_splice_delta_above_reserve_sat" but the code computes balance - delta.

  4. lightning/src/sign/tx_builder.rs:394 — Comment says "bump this maximum" but the code reduces it.

Previously Flagged Issues (Corrected)

My prior inline comments about unit mismatches in channel_state.rs:628, router.rs:4153, and router.rs:9653 were incorrect — all unit conversions are correct (they properly divide msat by 1000 to get sat).

Cross-Cutting Concern

Stale naming after semantic change: The value stored in PriorContribution changed from "holder balance floor" to "splice-out maximum". While the field and doc in funding.rs were updated to spliceable_balance, several call-site variable names remain holder_balance (channel.rs:12926, 13083, 13095-13099) and a log message still says "Cannot compute holder balance" (channel.rs:12888). The logic is correct but the naming inconsistency could confuse future contributors working on this finance-critical code.

@tankyleo tankyleo requested review from TheBlueMatt and wpaulino and removed request for jkczyz April 9, 2026 08:32
@tankyleo tankyleo self-assigned this Apr 9, 2026
@tankyleo tankyleo moved this to Goal: Merge in Weekly Goals Apr 9, 2026
@tankyleo
Copy link
Copy Markdown
Contributor Author

tankyleo commented Apr 9, 2026

Seems the fuzz failure produces this error on my machine

Hex:

ff1cb4a6a6ffffff02000000ffffb4a669696969696969691b00000000000000ffffffffffa2ad06301100ffffffffffffcf111f7d29000000000000001f0007

Target: chanmon_consistency

Error:

2      ERROR [lightning::ln::channelmanager:4635]      ch:8c0000 p:020000          Closed channel due to close-required error: Channel closed because of an exception: splice tx of another pending funding already confirmed
2      ERROR [lightning::ln::channelmanager:4557]      ch:8c0000 p:020000          Closing channel: Channel closed because of an exception: splice tx of another pending funding already confirmed

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot
Copy link
Copy Markdown

🔔 1st Reminder

Hey @TheBlueMatt @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@Alkamal01
Copy link
Copy Markdown

Curious about estimated_fees = 183 in do_test_splice_max_value — is that commit_tx_fee_sat(253, 0, legacy_channel) (the unspiked fee)? The test already subtracts commit_tx_fee_sat at the spiked feerate separately, so I wasn't sure what the 183 is covering.

@tankyleo
Copy link
Copy Markdown
Contributor Author

Curious about estimated_fees = 183 in do_test_splice_max_value — is that commit_tx_fee_sat(253, 0, legacy_channel) (the unspiked fee)? The test already subtracts commit_tx_fee_sat at the spiked feerate separately, so I wasn't sure what the 183 is covering.

Thanks for taking a look ! 183sats is the amount allocated to the fee of the splice out transaction at the default feerate of 253sat/kw, and will be added on top of the splice amount. Will clarify this in the test.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 2nd Reminder

Hey @TheBlueMatt @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

1 similar comment
@ldk-reviews-bot
Copy link
Copy Markdown

🔔 2nd Reminder

Hey @TheBlueMatt @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

Comment thread lightning/src/ln/channel.rs Outdated
their_contribution_candidate: SignedAmount, addl_nondust_htlc_count: usize,
feerate_per_kw: u32,
) -> Result<(Amount, Amount), String> {
let htlc_candidate = None;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This really feels like it could/should be substantially DRYd with get_next_local/remote_commitment_stats - what we're really asking at the callsites is "is this splice even valid" which istm should really be done by just saying "did get_next_local_commitment_stats return an Error or not" (with appropriate balance modification flags, maybe a wrapper method calling an internal impl method rather than modifying the existing methods).

Comment thread lightning/src/sign/tx_builder.rs Outdated
/// retains at least one output after the splice, which is particularly relevant for
/// zero-reserve channels.
//
// The equation to determine `percent_max_splice_out_sat` is:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discussed offline but this feels super unclear to me because "percent_" as a prefix implies to me, that the value is a percent, but its not.

@TheBlueMatt
Copy link
Copy Markdown
Collaborator

Needs rebase, sorry about that. Do think its worth doing this for 0.3, yes, given it fixed splice <-> 0 reserve compat. Its also not a big deal to add a field rn.

@TheBlueMatt TheBlueMatt added this to the 0.3 milestone Apr 13, 2026
@Alkamal01
Copy link
Copy Markdown

Curious about estimated_fees = 183 in do_test_splice_max_value — is that commit_tx_fee_sat(253, 0, legacy_channel) (the unspiked fee)? The test already subtracts commit_tx_fee_sat at the spiked feerate separately, so I wasn't sure what the 183 is covering.

Thanks for taking a look ! 183sats is the amount allocated to the fee of the splice out transaction at the default feerate of 253sat/kw, and will be added on top of the splice amount. Will clarify this in the test.

Makes sense, thanks for clarifying!

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 3rd Reminder

Hey @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@jkczyz jkczyz self-requested a review April 16, 2026 17:20
@ldk-reviews-bot
Copy link
Copy Markdown

🔔 4th Reminder

Hey @jkczyz @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 1st Reminder

Hey @jkczyz @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 5th Reminder

Hey @jkczyz @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@tankyleo tankyleo force-pushed the 2026-04-next-splice-out-maximum branch from 3bd7a76 to 245edbe Compare April 20, 2026 02:54
}
max_splice_out_sat
} else {
// In a zero-reserve channel, the holder is free to withdraw up to its `post_splice_delta_above_reserve_sat`
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This comment is backwards — it reads as if post_splice_delta_above_reserve_sat is the maximum the holder can withdraw, but the code computes local_balance - delta, meaning the holder can withdraw everything except the delta. Consider:

Suggested change
// In a zero-reserve channel, the holder is free to withdraw up to its `post_splice_delta_above_reserve_sat`
// In a zero-reserve channel, the holder is free to withdraw up to its balance minus `post_splice_delta_above_reserve_sat`

@tankyleo tankyleo force-pushed the 2026-04-next-splice-out-maximum branch from 245edbe to bb91e11 Compare April 20, 2026 03:02
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 20, 2026

Codecov Report

❌ Patch coverage is 90.74890% with 21 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.02%. Comparing base (6573d42) to head (ccde0da).

Files with missing lines Patch % Lines
lightning/src/ln/channel.rs 88.38% 11 Missing and 7 partials ⚠️
lightning/src/ln/channel_state.rs 66.66% 1 Missing ⚠️
lightning/src/ln/channelmanager.rs 0.00% 1 Missing ⚠️
lightning/src/routing/router.rs 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4550      +/-   ##
==========================================
+ Coverage   86.99%   87.02%   +0.02%     
==========================================
  Files         163      161       -2     
  Lines      109008   109019      +11     
  Branches   109008   109019      +11     
==========================================
+ Hits        94828    94870      +42     
+ Misses      11696    11662      -34     
- Partials     2484     2487       +3     
Flag Coverage Δ
fuzzing 39.64% <77.67%> (+1.42%) ⬆️
tests 86.12% <90.74%> (+0.02%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tankyleo tankyleo force-pushed the 2026-04-next-splice-out-maximum branch from bb91e11 to ccde0da Compare April 20, 2026 04:09
@tankyleo tankyleo requested a review from TheBlueMatt April 20, 2026 05:11
TheBlueMatt
TheBlueMatt previously approved these changes Apr 21, 2026
Copy link
Copy Markdown
Collaborator

@TheBlueMatt TheBlueMatt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments but largely LGTM, we can also address comments in a followup.

Comment thread lightning/src/ln/channel.rs Outdated
.get_holder_counterparty_balances_floor_incl_fee(&self.funding)
.map(|(h, _)| h)
.ok();
let holder_balance = self.get_next_splice_out_maximum(&self.funding).ok();
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to update the docs in funding.rs to match this, and maybe even rename the variables through to there.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@wpaulino WDYT ? perhaps in that module it makes sense to think of the "holder's balance" as "the maximum they can splice out of the channel".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think that's fine, could also refer to it as the "spliceable balance" if we don't want to explicitly call out "splice out"

Comment thread lightning/src/sign/tx_builder.rs Outdated
.saturating_add(100)
.saturating_sub(post_splice_delta_above_reserve_sat.saturating_mul(100))
.saturating_sub(channel_value_satoshis);
let max_splice_percentage_constraint_sat = dividend.saturating_sub(1) / 99;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The -1 doesn't appear in the math above?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a comment to clarify this, see below

Comment thread lightning/src/ln/channel.rs Outdated
let next_splice_out_maximum = self.get_next_splice_out_maximum(&self.funding)?;
let unsigned_contribution = our_funding_contribution.unsigned_abs();
next_splice_out_maximum.to_sat().checked_add_signed(our_funding_contribution.to_sat())
.ok_or(format!("Our splice-out value of {unsigned_contribution} is greater than the maximum {next_splice_out_maximum}"))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: can we debug assert that validate_splice_contributions passes? It has nice additional debug assertions i think we should try to retain.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

when we call get_next_splice_out_maximum we run validate_splice_contributions with the maximum applied and check that it passes

Comment on lines +6770 to +6773
// TODO: Skip 0FC channels for now as these always have an output on the commitment, the P2A
// output. We will be able to withdraw up to the dust limit of the funding script, which
// is checked in interactivetx. Still need to double check whether that's what we actually
// want.
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TheBlueMatt forgot to mention this TODO here, WDYT ?

@tankyleo
Copy link
Copy Markdown
Contributor Author

git diff ccde0da fb777b8, let me know when I can rebase

diff --git a/lightning/src/ln/channel.rs b/lightning/src/ln/channel.rs
index 8931b05bc..5b165a28b 100644
--- a/lightning/src/ln/channel.rs
+++ b/lightning/src/ln/channel.rs
@@ -12401,8 +12401,8 @@
 			"build_prior_contribution requires pending_splice"
 		);
 		let prior = self.pending_splice.as_ref()?.contributions.last()?;
-		let holder_balance = self.get_next_splice_out_maximum(&self.funding).ok();
-		Some(PriorContribution::new(prior.clone(), holder_balance))
+		let next_splice_out_maximum = self.get_next_splice_out_maximum(&self.funding).ok();
+		Some(PriorContribution::new(prior.clone(), next_splice_out_maximum))
 	}

 	/// Returns whether this channel can ever RBF, independent of splice state.
@@ -12476,13 +12476,13 @@
 			return contribution;
 		}

-		let holder_balance = match self.get_next_splice_out_maximum(&self.funding) {
+		let next_splice_out_maximum = match self.get_next_splice_out_maximum(&self.funding) {
 			Ok(balance) => balance,
 			Err(_) => return contribution,
 		};

-		if let Err(e) =
-			contribution.net_value_for_initiator_at_feerate(min_rbf_feerate, holder_balance)
+		if let Err(e) = contribution
+			.net_value_for_initiator_at_feerate(min_rbf_feerate, next_splice_out_maximum)
 		{
 			log_info!(
 				logger,
@@ -12503,7 +12503,7 @@
 			min_rbf_feerate,
 		);
 		contribution
-			.for_initiator_at_feerate(min_rbf_feerate, holder_balance)
+			.for_initiator_at_feerate(min_rbf_feerate, next_splice_out_maximum)
 			.expect("feerate compatibility already checked")
 	}

@@ -12876,7 +12876,7 @@
 	fn resolve_queued_contribution<L: Logger>(
 		&self, feerate: FeeRate, logger: &L,
 	) -> Result<(Option<SignedAmount>, Option<Amount>), ChannelError> {
-		let holder_balance = self
+		let next_splice_out_maximum = self
 			.get_next_splice_out_maximum(&self.funding)
 			.map_err(|e| {
 				log_info!(
@@ -12889,9 +12889,12 @@
 			})
 			.ok();

-		let net_value = match holder_balance.and_then(|_| self.queued_funding_contribution()) {
+		let net_value = match next_splice_out_maximum
+			.and_then(|_| self.queued_funding_contribution())
+		{
 			Some(c) => {
-				match c.net_value_for_acceptor_at_feerate(feerate, holder_balance.unwrap()) {
+				match c.net_value_for_acceptor_at_feerate(feerate, next_splice_out_maximum.unwrap())
+				{
 					Ok(net_value) => Some(net_value),
 					Err(FeeRateAdjustmentError::FeeRateTooHigh { .. }) => {
 						return Err(ChannelError::Abort(AbortReason::FeeRateTooHigh));
@@ -12911,7 +12914,7 @@
 			None => None,
 		};

-		Ok((net_value, holder_balance))
+		Ok((net_value, next_splice_out_maximum))
 	}

 	pub(crate) fn splice_init<ES: EntropySource, L: Logger>(
diff --git a/lightning/src/sign/tx_builder.rs b/lightning/src/sign/tx_builder.rs
index d78f65d22..ade54c603 100644
--- a/lightning/src/sign/tx_builder.rs
+++ b/lightning/src/sign/tx_builder.rs
@@ -324,7 +324,7 @@
 //
 // The equation to determine `max_splice_percentage_constraint_sat` is:
 // 1) floor((c - s) / 100) == h - s - d
-// We want the maximum value of s that will satisfy this equation, therefore, we solve:
+// We want the maximum value of s that will satisfy equation 1, therefore, we solve:
 // 2) (c - s) / 100 < h - s - d + 1
 // where c: `channel_value_satoshis`
 //       s: `max_splice_percentage_constraint_sat`
@@ -343,16 +343,17 @@
 		.counterparty_selected_channel_reserve_satoshis
 		!= 0
 	{
-		let dividend = local_balance_before_fee_sat
+		let dividend_sat = local_balance_before_fee_sat
 			.saturating_mul(100)
 			.saturating_add(100)
 			.saturating_sub(post_splice_delta_above_reserve_sat.saturating_mul(100))
 			.saturating_sub(channel_value_satoshis);
-		let max_splice_percentage_constraint_sat = dividend.saturating_sub(1) / 99;
+		// Calculate the greatest integer that is strictly less than the RHS of inequality 3 above
+		let max_splice_percentage_constraint_sat = dividend_sat.saturating_sub(1) / 99;
 		let max_splice_dust_limit_constraint_sat = local_balance_before_fee_sat
 			.saturating_sub(channel_constraints.holder_dust_limit_satoshis)
 			.saturating_sub(post_splice_delta_above_reserve_sat);
-		// Take whichever constraint you hit first as you increase the value of the splice-out
+		// Both constraints must be satisfied, so take the minimum of the two maximums
 		let max_splice_out_sat =
 			cmp::min(max_splice_percentage_constraint_sat, max_splice_dust_limit_constraint_sat);
 		#[cfg(debug_assertions)]

@wpaulino
Copy link
Copy Markdown
Contributor

Needs a rebase

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 2nd Reminder

Hey @jkczyz @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

@ldk-reviews-bot
Copy link
Copy Markdown

🔔 6th Reminder

Hey @jkczyz @wpaulino! This PR has been waiting for your review.
Please take a look when you have a chance. If you're unable to review, please let us know so we can find another reviewer.

As a result, we now validate that both commitments retain at least one
output under the new funding scope, which is crucial for zero-reserve
channels.
We previously determined this value by subtracting the htlcs, the
anchors, and the commitment transaction fee. This ignored the reserve,
as well as the at-least-one-output requirement in zero-reserve channels.

This new field now accounts for both of these constraints. It can be
seen as the total spliceable balance from the channel.
This is equivalent to the previous commit, see the debug assertions
added in the previous commit. We now also get to communicate the
exact maximum back to the user, instead of some "balance is lower
than our reserve" message, which is hard to react to.
@tankyleo tankyleo force-pushed the 2026-04-next-splice-out-maximum branch from fb777b8 to d3b1c31 Compare April 22, 2026 02:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Goal: Merge

Development

Successfully merging this pull request may close these issues.

6 participants